概要
オブジェクトのスポーンは特に大量に行う場合、パフォーマンスに大きな影響を与えます。これを解決するために、Object Pool(Unreal Engine では Actor Pool)が役に立ちます。 あらかじめ(例:ゲームローディング画面の時)アクターをに生成して非アクティブ・非表示にして、Actor Pool に格納しおきます。 必要なときにプールから取り出して利用します。不要になったら破棄せずにプールに戻すことで再利用可能にします。
Fab では多数のオブジェクトプールやアクタープールのプラグインが配布されていますが、実は Actor Pool の実装はそれほど難しくありません。なので、自分で作成してみました。
この Actor Pool の実装はマルチプレイヤーに対応しており、基本的にサーバー側のみで動作します。これは、クライアントがレプリケートされたゲームプレイアクターを直接スポーンすることがないためです。
📚 オブジェクトプールに関する資料
- 🎮 Object Pool とは(ランカース開発ブログ)
- 📘 Game Programming Patterns – Object Pool
- 📖 Wikipedia – Object Pool Pattern
開発環境
- Unreal Engine 5.6.0
- Windows 11 Pro
本編
通常のスポーン処理のパフォーマンス
Actor Pool を実装する前に、Projectile をスポーンする際のパフォーマンスを測定しました。
ServerSpawnProjectile
の最大時間: 363.3 µs
見ての通り、SpawnActor (359.2 µs)
の中で ConstructObject (78.8 µs)
や RegisterAllComponents (122.9 µs)
が処理されています。これらは Actor Pool を使えばスキップ可能で、BeginPlay
中身のロジックのみが必要になります。
💡 補足:
Blueprint Time (362.4 - 359.2 = 3.2 µs)
のコストは、ServerSpawnProjectile
がUFUNCTION()
でマークされているため、Blueprint の仮想マシン(Virtual Machine)が起動されてしまうからです。本来 C++関数で完結しており、これは不要なオーバーヘッドです(エンジンの問題)。
アクターをスポーンすると、多くの初期化処理が実行され、パフォーマンスに悪影響を与えることがわかります。
以下は、Actor Pool 内でのスポーンおよびデスポーンのフローチャートです。
C++で Actor Pool を作成する
Pool 対象の Actor 向けに IPoolableInterface
を作成する
アクターがプールからアクティブ化されたとき、またはプールに戻されたときに呼び出されるインターフェースを作成します。これにより、カスタムロジックを実装できるようになります。
プールされたアクターでは BeginPlay
や EndPlay
が実行されないため、このインターフェースを使ってライフサイクルの動作を手動で管理する必要があります。
title=YourProject/Core/Interface/IPoolableInterface.h1#pragma once 2 3#include "CoreMinimal.h" 4#include "UObject/Interface.h" 5#include "IPoolableInterface.generated.h" 6 7class UActorPool; 8 9UINTERFACE(MinimalAPI) 10class UPoolableInterface : public UInterface 11{ 12 GENERATED_BODY() 13}; 14 15/** 16 * Interface for actors that can be managed by the actor pool system. 17 * Provides callbacks for when actors are activated from or returned to the pool. 18 */ 19class YOUR_API IPoolableInterface 20{ 21 GENERATED_BODY() 22 23public: 24 /** 25 * Called when an actor is retrieved from the pool and activated for gameplay. 26 * Use this to start timers, initialize state, and prepare for active use. 27 * @param InActorPool The pool this actor belongs to 28 * @param Location The world location to spawn at 29 * @param Rotation The world rotation to spawn with 30 * @param SpawnParameters Additional spawn parameters 31 */ 32 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) = 0; 33 34 /** 35 * Called when an actor is being returned to the pool and deactivated. 36 * Use this to clear timers, reset state, and prepare for pool storage. 37 */ 38 virtual void OnDeactivateFromPool() = 0; 39};
ActorPool クラスを作成する
プールの実装には UObject
を使います。多くの開発者やプラグインはマネージャーやサブシステム、もしくは ActorComponent
(Actor
にしかアタッチできない)などの集中管理システムを使いますが、私は柔軟性の高い UObject
ベースの実装の方が良いと思います。。
UObject
ベースのプールは、アクター、ゲームモード、サブシステム、コンポーネントなど、どんなクラスにも所有・管理させることができ、必要な場所に簡単に組み込めます。この分散型アプローチは設計がシンプルでデバッグしやすく、プリウォーム数(プールサイズ)などの設定も容易です。
プリウォーム(Prewarm): あらかじめ生成して格納すること
また、アクターコンポーネントやグローバルなサブシステムと比べて疎結合で再利用が促進され、オーバーヘッドも少なくなります。
ヘッダーファイル:
YourProject/Core/Utility/Object/ActorPool.h1 2#pragma once 3 4#include "CoreMinimal.h" 5#include "Engine/World.h" 6#include "UObject/Object.h" 7#include "ActorPool.generated.h" 8 9/** 10 * 11 */ 12UCLASS() 13class YOUR_API UActorPool : public UObject 14{ 15 GENERATED_BODY() 16 17public: 18 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 19 void InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount = 5); 20 21 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 22 void ReturnToPool(AActor* Actor); 23 24public: 25 AActor* TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 26 const FActorSpawnParameters& SpawnParameters = FActorSpawnParameters()); 27 28 FORCEINLINE bool IsEmpty() const 29 { 30 return PooledActors.Num() == 0; 31 } 32 33 FORCEINLINE int32 GetSize() const 34 { 35 return PooledActors.Num(); 36 } 37 38 FORCEINLINE void PushActor(AActor* Actor); 39 40 AActor* PopActor(); 41 42protected: 43 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool") 44 TSubclassOf<AActor> ActorClass; 45 46 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 47 int32 PrewarmCount = 5; 48 49 UPROPERTY() 50 TArray<TObjectPtr<AActor>> PooledActors; 51 52private: 53 void PrewarmPool(); 54 void ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 55 const FActorSpawnParameters& SpawnParameters); 56 void DeactivatePooledActor(AActor* Actor); 57 58private: 59 // Stats 60 int32 PoolMisses = 0; 61}; 62
💡 重要:
PooledActors
に必ずUPROPERTY()
を付けてください。そうしないと、Unreal のガベージコレクター(GC)によってアクターが予期せず破棄され、デバッグが難しい問題が発生する可能性があります 😨。
cpp ファイル:
YourProject/Core/Utility/Object/ActorPool.cpp1 2 3#include "ActorPool.h" 4 5#include <YourProject/Core/Interface/IPoolableInterface.h> 6 7DEFINE_LOG_CATEGORY_STATIC(LogActorPool, Log, All); 8 9void UActorPool::InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount) 10{ 11 if (!IsValid(InActorClass)) 12 { 13 UE_LOG(LogActorPool, Error, TEXT("UActorPool::InitializePool - Invalid Actor Class")); 14 return; 15 } 16 17 ActorClass = InActorClass; 18 PrewarmCount = InPrewarmCount; 19 PooledActors.Empty(); 20 PrewarmPool(); 21} 22 23void UActorPool::PrewarmPool() 24{ 25 if (!IsValid(ActorClass) || PrewarmCount <= 0) 26 { 27 UE_LOG(LogActorPool, Warning, TEXT("UActorPool::PrewarmPool - Invalid Actor Class or Prewarm Count")); 28 return; 29 } 30 31 UWorld* World = GetWorld(); 32 if (!IsValid(World)) 33 { 34 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Invalid World")); 35 return; 36 } 37 for (int32 i = 0; i < PrewarmCount; ++i) 38 { 39 FActorSpawnParameters SpawnParams; 40 SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; 41 42 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, FVector::ZeroVector, FRotator::ZeroRotator, 43 SpawnParams); 44 if (IsValid(NewActor)) 45 { 46 DeactivatePooledActor(NewActor); 47 PushActor(NewActor); 48 UE_LOG(LogActorPool, Log, TEXT("Prewarmed actor %s (%d/%d)"), 49 *NewActor->GetName(), i + 1, PrewarmCount); 50 } 51 else 52 { 53 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Failed to spawn actor %s"), 54 *ActorClass->GetName()); 55 } 56 } 57} 58 59AActor* UActorPool::TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 60 const FActorSpawnParameters& SpawnParameters) 61{ 62 if (!IsValid(ActorClass) || PrewarmCount <= 0) 63 { 64 UE_LOG(LogActorPool, Warning, 65 TEXT("UActorPool::TrySpawnPooledActor - Invalid Actor Class or Prewarm Count")); 66 return nullptr; 67 } 68 69 70 UWorld* World = GetWorld(); 71 if (!IsValid(World)) 72 { 73 UE_LOG(LogActorPool, Warning, TEXT("TrySpawnPooledActor: Invalid World")); 74 return nullptr; 75 } 76 77 if (AActor* PooledActor = PopActor()) 78 { 79 ActivatePooledActor(PooledActor, Location, Rotation, SpawnParameters); 80 UE_LOG(LogActorPool, Log, TEXT("Spawned pooled actor: %s at location: %s, rotation: %s"), 81 *PooledActor->GetName(), *Location.ToString(), *Rotation.ToString()); 82 return PooledActor; 83 } 84 85 PoolMisses++; 86 UE_LOG(LogActorPool, Warning, 87 TEXT("Pool empty for class: %s, falling back to spawn new actor. Pool Misses: %d, Prewarm Count: %d"), 88 *ActorClass->GetName(), PoolMisses, PrewarmCount); 89 90 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParameters); 91 if (IsValid(NewActor)) 92 { 93 ActivatePooledActor(NewActor, Location, Rotation, SpawnParameters); 94 } 95 return NewActor; 96} 97 98void UActorPool::ReturnToPool(AActor* Actor) 99{ 100 if (!IsValid(Actor)) 101 { 102 return; 103 } 104 105 // Check authority for network safety 106 if (Actor->GetLocalRole() != ROLE_Authority) 107 { 108 UE_LOG(LogActorPool, Error, TEXT("Not Authority, cannot return actor to pool: %s"), *Actor->GetName()); 109 return; 110 } 111 112 DeactivatePooledActor(Actor); 113 PushActor(Actor); 114} 115 116void UActorPool::ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 117 const FActorSpawnParameters& SpawnParameters) 118{ 119 if (!IsValid(Actor)) 120 { 121 return; 122 } 123 124 // Cache root component lookup to avoid repeated virtual calls 125 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 126 127 // Batch actor transform and ownership changes 128 Actor->SetActorLocationAndRotation(Location, Rotation); 129 Actor->SetOwner(SpawnParameters.Owner); 130 Actor->SetInstigator(SpawnParameters.Instigator); 131 132 // Batch actor state changes 133 Actor->SetActorHiddenInGame(false); 134 Actor->SetActorEnableCollision(true); 135 Actor->SetActorTickEnabled(true); 136 137 // Reset physics state if primitive component exists 138 if (RootPrimitive) 139 { 140 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 141 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 142 } 143 144 Actor->Reset(); 145 146 // Call poolable interface if implemented 147 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 148 { 149 PoolableInterface->OnActivateFromPool(this, Location, Rotation, SpawnParameters); 150 } 151} 152 153void UActorPool::DeactivatePooledActor(AActor* Actor) 154{ 155 // Call poolable interface first to allow cleanup before state changes 156 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 157 { 158 PoolableInterface->OnDeactivateFromPool(); 159 } 160 161 // Cache root component lookup to avoid repeated virtual calls 162 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 163 164 // Batch actor state changes 165 Actor->SetActorHiddenInGame(true); 166 Actor->SetActorEnableCollision(false); 167 Actor->SetActorTickEnabled(false); 168 169 // Reset physics state if primitive component exists 170 if (RootPrimitive) 171 { 172 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 173 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 174 } 175 176 // Clear ownership references 177 Actor->SetOwner(nullptr); 178 Actor->SetInstigator(nullptr); 179} 180 181void UActorPool::PushActor(AActor* Actor) 182{ 183 PooledActors.Add(Actor); 184} 185 186AActor* UActorPool::PopActor() 187{ 188 while (IsEmpty() == false) 189 { 190 if (AActor* Actor = PooledActors.Pop(); IsValid(Actor)) 191 { 192 return Actor; 193 } 194 } 195 return nullptr; 196}
プール内のアクターが不足している場合、プールは通常のスポーン処理にフォールバックし、動的にプールを拡張します。また、プールミス(Pool Miss: プールサイズが足りない數)の回数をカウントして警告ログを出すため、プリウォームのプールサイズを調整しやすくなっています。
使用例
プール可能なアクターにこのインターフェースを実装し、OnActivateFromPool()
と OnDeactivateFromPool()
にカスタムロジックを追加してください。
例えば、私のプロジェクタイルクラスは自身の ActorPool
へのポインタを保持しており、破棄される代わりにプールに返却することができます。
ヘッダー:
ProjectileBase.h1 2// ... 3 4public: 5// IPoolableInterface 6 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) override; 7 virtual void OnDeactivateFromPool() override; 8 9 void ReturnToPoolOrDestroy(); 10 11protected: 12UPROPERTY() 13 TObjectPtr<UActorPool> ActorPool;
ProjectileBase.cpp1 2void AProjectileBase::OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, 3 const FActorSpawnParameters& SpawnParameters) 4{ 5 ActorPool = InActorPool; 6 7 // Recalculate projectile velocity based on rotation 8 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 9 { 10 // Ensure the movement component has the correct UpdatedComponent 11 if (CollisionComp) 12 { 13 MovementComp->SetUpdatedComponent(CollisionComp); 14 } 15 16 // Calculate velocity based on spawn rotation, not actor forward (which might be wrong for pooled actors) 17 FVector InitialVelocity = Rotation.Vector() * MovementComp->InitialSpeed; 18 19 MovementComp->StartSimulating(InitialVelocity); 20 } 21 else 22 { 23 UE_LOG(LogTemp, Error, TEXT("OnPoolActivate - No movement component found!")); 24 } 25} 26 27void AProjectileBase::OnDeactivateFromPool() 28{ 29 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 30 { 31 MovementComp->StopSimulating(FHitResult()); 32 33 MovementComp->UpdateComponentVelocity(); 34 } 35} 36 37 38void AProjectileBase::ReturnToPoolOrDestroy() 39{ 40 if (!HasAuthority()) 41 { 42 return; 43 } 44 45 if (!IsValid(ActorPool)) 46 { 47 UE_LOG(LogTemp, Warning, TEXT("AProjectileBase::ReturnToPoolOrDestroy - No ActorPool set! Destroying actor instead.")); 48 Destroy(); 49 return; 50 } 51 52 ActorPool->ReturnToPool(this); 53 54}
Destroy()
の代わりに ReturnToPoolOrDestroy()
を呼び出します。
プール可能なアクターをスポーンするクラスでは、ヘッダーファイルに ActorPool
を宣言し、プールに格納予定のアクタークラス(ProjectileClass
)を保持します。
YourClass.h1class UActorPool; 2 3//... 4{ 5 6//... 7 8protected: 9 10//... 11 UPROPERTY(EditDefaultsOnly, Category=Projectile) 12 TSubclassOf<class APawProjectileBase> ProjectileClass; 13 14 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor Pool") 15 TObjectPtr<UActorPool> ProjectilePool; 16 17 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 18 int32 PrewarmCount = 3; 19}
アクタープールを初期化し、プールにクラスとプリウォーム数を渡します。
YourClass.cpp1 2// can be during your custom Game Loading 3// or just put in BeginPlay() 4ProjectilePool->InitializePool(ProjectileClass, PrewarmCount); 5// ...
プールされたアクターをスポーンする
YourClass.cpp1// ... 2if (const UWorld* World = GetWorld(); IsValid(World)) 3 { 4 //Set Spawn Collision Handling Override 5 FActorSpawnParameters ActorSpawnParams; 6 7 // Specify the spawn params 8 9 // ActorSpawnParams.SpawnCollisionHandlingOverride = 10 // ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; 11 // ActorSpawnParams.Owner = FPSPlayer; 12 // ActorSpawnParams.Instigator = FPSPlayer; 13 AActor* SpawnProjectile = ProjectilePool->TrySpawnPooledActor( 14 SpawnLocation, SpawnRotation, ActorSpawnParams); 15 } 16//...
完了しました!
この Actor Pool は、AI エネミーやアイテムなど、アクターであれば他のゲームプレイオブジェクトのプーリングにも可能です。
結果
Actor Pool 導入前
ServerSpawnProjectile
の最大時間:363.3 µs
Actor Pool 導入後
ServerSpawnProjectile
の最大時間:187.2 µs
ConstructObject
と RegisterAllComponents
は Actor Pool の利用で回避でき、BeginPlay
のロジックは OnActivateFromPool
に移動し、それだけが必要になっています。
指標 | 導入前 (B4) | 導入後 (AFT) | 改善度 |
---|---|---|---|
最小時間 | 285.6 μs | 99.2 μs | 約 2.88 倍高速化 |
最大時間 | 363.3 μs | 187.2 μs | 約 1.94 倍高速化 |
Actor Pool 導入前はプロジェクタイルのスポーンに285.6 ~ 363.3 μsかかっていました。
導入後はプールされたプロジェクタイルのアクティベートに99.2 ~ 187.2 μsだけかかります。
重いスポーンコストはプリウォーム段階で前倒しされているため、実行時は高速化になり、約 1.94 倍~約 2.88 倍高速化しました。
結論
Unreal Engine 5 で C++を使ってActor Pool
(Object Pool)を導入することは、頻繁にスポーン・破棄されるプロジェクタイルやエフェクト、敵キャラなどの生成する処理のパフォーマンスを改善しました。
UObject
でプールを実装することで、柔軟性・疎結合・再利用性が高まり、ゲーム全体のアーキテクチャにおいても扱いやすくなります。
ゲームインスタンス、ゲームモード、サブシステム、アクター、コンポーネントなど、どこから管理してもよいこの分散型の設計は、シンプルでデバッグしやすく、カスタマイズ性も高いです。
もし誤りがあれば、コメントでご指摘いただけると幸いです。